route.test.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
  3. import fs from "node:fs/promises";
  4. import os from "node:os";
  5. import path from "node:path";
  6. import fsp from "node:fs/promises";
  7. vi.mock("@/lib/auth/session", () => ({
  8. getSession: vi.fn(),
  9. }));
  10. import { getSession } from "@/lib/auth/session";
  11. import { GET, dynamic, runtime } from "./route.js";
  12. describe("GET /api/files/[branch]/[year]/[month]/[day]/[filename]", () => {
  13. let tmpRoot;
  14. const originalNasRoot = process.env.NAS_ROOT_PATH;
  15. const paramsOk = {
  16. branch: "NL01",
  17. year: "2024",
  18. month: "10",
  19. day: "23",
  20. filename: "test.pdf",
  21. };
  22. beforeEach(async () => {
  23. vi.clearAllMocks();
  24. tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-pdf-"));
  25. process.env.NAS_ROOT_PATH = tmpRoot;
  26. const dir = path.join(tmpRoot, "NL01", "2024", "10", "23");
  27. await fs.mkdir(dir, { recursive: true });
  28. await fs.writeFile(path.join(dir, "test.pdf"), "dummy-pdf-content");
  29. });
  30. afterEach(async () => {
  31. process.env.NAS_ROOT_PATH = originalNasRoot;
  32. if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
  33. vi.restoreAllMocks();
  34. });
  35. it('exports dynamic="force-dynamic" (RHL-006)', () => {
  36. expect(dynamic).toBe("force-dynamic");
  37. });
  38. it('exports runtime="nodejs" (required for streaming)', () => {
  39. expect(runtime).toBe("nodejs");
  40. });
  41. it("returns 401 when unauthenticated", async () => {
  42. getSession.mockResolvedValue(null);
  43. const res = await GET(
  44. new Request("http://localhost/api/files/NL01/2024/10/23/test.pdf"),
  45. { params: Promise.resolve(paramsOk) }
  46. );
  47. expect(res.status).toBe(401);
  48. expect(await res.json()).toEqual({
  49. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  50. });
  51. });
  52. it("returns 403 when branch access is forbidden", async () => {
  53. getSession.mockResolvedValue({
  54. role: "branch",
  55. branchId: "NL01",
  56. userId: "u1",
  57. });
  58. const res = await GET(
  59. new Request("http://localhost/api/files/NL02/2024/10/23/test.pdf"),
  60. {
  61. params: Promise.resolve({
  62. ...paramsOk,
  63. branch: "NL02",
  64. }),
  65. }
  66. );
  67. expect(res.status).toBe(403);
  68. expect(await res.json()).toEqual({
  69. error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
  70. });
  71. });
  72. it("returns 400 for non-pdf filename", async () => {
  73. getSession.mockResolvedValue({
  74. role: "admin",
  75. branchId: null,
  76. userId: "u2",
  77. });
  78. const res = await GET(
  79. new Request("http://localhost/api/files/NL01/2024/10/23/test.txt"),
  80. {
  81. params: Promise.resolve({
  82. ...paramsOk,
  83. filename: "test.txt",
  84. }),
  85. }
  86. );
  87. expect(res.status).toBe(400);
  88. expect(await res.json()).toEqual({
  89. error: {
  90. message: "Only PDF files are allowed",
  91. code: "VALIDATION_FILE_EXTENSION",
  92. details: { filename: "test.txt" },
  93. },
  94. });
  95. });
  96. it("returns 400 for unsafe filename", async () => {
  97. getSession.mockResolvedValue({
  98. role: "admin",
  99. branchId: null,
  100. userId: "u2",
  101. });
  102. const res = await GET(
  103. new Request("http://localhost/api/files/NL01/2024/10/23/foo/bar.pdf"),
  104. {
  105. params: Promise.resolve({
  106. ...paramsOk,
  107. filename: "foo/bar.pdf",
  108. }),
  109. }
  110. );
  111. expect(res.status).toBe(400);
  112. expect(await res.json()).toEqual({
  113. error: {
  114. message: "Invalid filename parameter",
  115. code: "VALIDATION_FILENAME",
  116. details: { filename: "foo/bar.pdf" },
  117. },
  118. });
  119. });
  120. it("returns 404 when the PDF does not exist (authorized)", async () => {
  121. getSession.mockResolvedValue({
  122. role: "admin",
  123. branchId: null,
  124. userId: "u2",
  125. });
  126. const res = await GET(
  127. new Request("http://localhost/api/files/NL01/2024/10/23/missing.pdf"),
  128. {
  129. params: Promise.resolve({
  130. ...paramsOk,
  131. filename: "missing.pdf",
  132. }),
  133. }
  134. );
  135. expect(res.status).toBe(404);
  136. expect(await res.json()).toEqual({
  137. error: {
  138. message: "Not found",
  139. code: "FS_NOT_FOUND",
  140. details: {
  141. branch: "NL01",
  142. year: "2024",
  143. month: "10",
  144. day: "23",
  145. filename: "missing.pdf",
  146. },
  147. },
  148. });
  149. });
  150. it("returns 500 for other filesystem errors (mocked)", async () => {
  151. getSession.mockResolvedValue({
  152. role: "admin",
  153. branchId: null,
  154. userId: "u2",
  155. });
  156. const spy = vi
  157. .spyOn(fsp, "stat")
  158. .mockRejectedValue(
  159. Object.assign(new Error("EACCES"), { code: "EACCES" })
  160. );
  161. const res = await GET(
  162. new Request("http://localhost/api/files/NL01/2024/10/23/test.pdf"),
  163. { params: Promise.resolve(paramsOk) }
  164. );
  165. expect(res.status).toBe(500);
  166. expect(await res.json()).toEqual({
  167. error: { message: "Internal server error", code: "FS_STORAGE_ERROR" },
  168. });
  169. spy.mockRestore();
  170. });
  171. it("streams the PDF with inline Content-Disposition by default", async () => {
  172. getSession.mockResolvedValue({
  173. role: "admin",
  174. branchId: null,
  175. userId: "u2",
  176. });
  177. const res = await GET(
  178. new Request("http://localhost/api/files/NL01/2024/10/23/test.pdf"),
  179. { params: Promise.resolve(paramsOk) }
  180. );
  181. expect(res.status).toBe(200);
  182. expect(res.headers.get("Content-Type")).toBe("application/pdf");
  183. expect(res.headers.get("Cache-Control")).toBe("no-store");
  184. expect(res.headers.get("Content-Disposition")).toBe(
  185. 'inline; filename="test.pdf"'
  186. );
  187. const buf = await res.arrayBuffer();
  188. expect(Buffer.from(buf).toString("utf8")).toBe("dummy-pdf-content");
  189. });
  190. it("uses attachment disposition when download=1", async () => {
  191. getSession.mockResolvedValue({
  192. role: "admin",
  193. branchId: null,
  194. userId: "u2",
  195. });
  196. const res = await GET(
  197. new Request(
  198. "http://localhost/api/files/NL01/2024/10/23/test.pdf?download=1"
  199. ),
  200. { params: Promise.resolve(paramsOk) }
  201. );
  202. expect(res.status).toBe(200);
  203. expect(res.headers.get("Content-Disposition")).toBe(
  204. 'attachment; filename="test.pdf"'
  205. );
  206. });
  207. });